Frigør potentialet i parallel behandling i JavaScript. Lær at håndtere samtidige Promises med Promise.all, allSettled, race og any for hurtigere, mere robuste applikationer.
Mestring af Concurrency i JavaScript: En Dybdegående Guide til Parallel Behandling af Promises
I det moderne landskab for webudvikling er performance ikke en feature; det er et grundlæggende krav. Brugere over hele kloden forventer, at applikationer er hurtige, responsive og problemfri. Kernen i denne performanceudfordring, især i JavaScript, er konceptet om at håndtere asynkrone operationer effektivt. Fra hentning af data fra et API til læsning af en fil eller forespørgsler til en database er der mange opgaver, der ikke fuldføres øjeblikkeligt. Hvordan vi håndterer disse venteperioder, kan gøre forskellen mellem en træg applikation og en vidunderligt flydende brugeroplevelse.
JavaScript er af natur et enkelttrådet sprog. Det betyder, at det kun kan udføre én kodestykke ad gangen. Dette lyder måske som en begrænsning, men JavaScripts event loop og ikke-blokerende I/O-model giver det mulighed for at håndtere asynkrone opgaver med utrolig effektivitet. Den moderne hjørnesten i denne model er Promise—et objekt, der repræsenterer den endelige fuldførelse (eller fejl) af en asynkron operation.
Men blot at bruge Promises eller deres elegante `async/await`-syntaks garanterer ikke automatisk optimal performance. En almindelig faldgrube for udviklere er at håndtere flere uafhængige asynkrone opgaver sekventielt, hvilket skaber unødvendige flaskehalse. Det er her, samtidig (concurrent) promise-behandling kommer ind i billedet. Ved at starte flere asynkrone operationer parallelt og vente på dem samlet, kan vi dramatisk reducere den samlede eksekveringstid og bygge langt mere effektive applikationer.
Denne omfattende guide vil tage dig på et dybdegående kig ind i verdenen af JavaScript concurrency. Vi vil udforske de værktøjer, der er bygget direkte ind i sproget—`Promise.all()`, `Promise.allSettled()`, `Promise.race()` og `Promise.any()`—for at hjælpe dig med at orkestrere parallelle opgaver som en professionel. Uanset om du er en juniorudvikler, der er ved at få styr på asynkronicitet, eller en erfaren ingeniør, der ønsker at finpudse dine mønstre, vil denne artikel udstyre dig med viden til at skrive hurtigere, mere robust og mere sofistikeret JavaScript-kode.
Først en hurtig afklaring: Concurrency vs. Parallelisme
Før vi fortsætter, er det vigtigt at afklare to begreber, der ofte bruges i flæng, men som har forskellige betydninger i datalogi: concurrency og parallelisme.
- Concurrency er konceptet om at håndtere flere opgaver over en periode. Det handler om at håndtere mange ting på én gang. Et system er concurrent, hvis det kan starte, køre og fuldføre mere end én opgave uden at vente på, at den forrige er færdig. I JavaScripts enkelttrådede miljø opnås concurrency via event loop'en, som giver motoren mulighed for at skifte mellem opgaver. Mens en langvarig opgave (som en netværksanmodning) venter, kan motoren arbejde på andre ting.
- Parallelisme er konceptet om at udføre flere opgaver samtidigt. Det handler om at gøre mange ting på én gang. Ægte parallelisme kræver en flerkerneprocessor, hvor forskellige tråde kan køre på forskellige kerner på præcis samme tid. Mens web workers tillader ægte parallelisme i browser-baseret JavaScript, vedrører den centrale concurrency-model, vi diskuterer her, den primære enkelttråd.
For I/O-bundne operationer (som netværksanmodninger) giver JavaScripts samtidige model *effekten* af parallelisme. Vi kan starte flere anmodninger på én gang. Mens JavaScript-motoren venter på svar, er den fri til at udføre andet arbejde. Operationerne sker 'parallelt' set fra de eksterne ressourcers perspektiv (servere, filsystemer). Dette er den kraftfulde model, vi vil udnytte.
Den Sekventielle Fælde: Et Almindeligt Anti-mønster
Lad os starte med at identificere en almindelig fejl. Når udviklere først lærer `async/await`, er syntaksen så ren, at det er let at skrive kode, der ser synkron ud, men som utilsigtet er sekventiel og ineffektiv. Forestil dig, at du skal hente en brugers profil, deres seneste indlæg og deres notifikationer for at bygge et dashboard.
En naiv tilgang kunne se sådan ud:
Eksempel: Den Ineffektive Sekventielle Hentning
async function fetchDashboardDataSequentially(userId) {
console.time('sequentialFetch');
console.log('Fetching user profile...');
const userProfile = await fetchUserProfile(userId); // Venter her
console.log('Fetching user posts...');
const userPosts = await fetchUserPosts(userId); // Venter her
console.log('Fetching user notifications...');
const userNotifications = await fetchUserNotifications(userId); // Venter her
console.timeEnd('sequentialFetch');
return { userProfile, userPosts, userNotifications };
}
// Forestil dig, at disse funktioner tager tid at afslutte
// fetchUserProfile -> 500ms
// fetchUserPosts -> 800ms
// fetchUserNotifications -> 1000ms
Hvad er der galt med dette billede? Hvert `await`-nøgleord pauser udførelsen af `fetchDashboardDataSequentially`-funktionen, indtil det pågældende promise er afklaret. Anmodningen om `userPosts` starter ikke engang, før `userProfile`-anmodningen er helt fuldført. Anmodningen om `userNotifications` starter ikke, før `userPosts` er modtaget. Disse tre netværksanmodninger er uafhængige af hinanden; der er ingen grund til at vente! Den samlede tid vil være summen af alle de individuelle tider:
Total Tid ≈ 500ms + 800ms + 1000ms = 2300ms
Dette er en enorm performance-flaskehals. Vi kan gøre det meget, meget bedre.
Frigørelse af Performance: Kraften i Samtidig Udførelse
Løsningen er at starte alle de asynkrone operationer på én gang uden straks at afvente dem. Dette giver dem mulighed for at køre samtidigt. Vi kan gemme de ventende Promise-objekter i variabler og derefter bruge en Promise combinator til at vente på, at de alle er fuldført.
Eksempel: Den Effektive Samtidige Hentning
async function fetchDashboardDataConcurrently(userId) {
console.time('concurrentFetch');
console.log('Initiating all fetches at once...');
const profilePromise = fetchUserProfile(userId);
const postsPromise = fetchUserPosts(userId);
const notificationsPromise = fetchUserNotifications(userId);
// Nu venter vi på, at de alle fuldføres
const [userProfile, userPosts, userNotifications] = await Promise.all([
profilePromise,
postsPromise,
notificationsPromise
]);
console.timeEnd('concurrentFetch');
return { userProfile, userPosts, userNotifications };
}
I denne version kalder vi de tre hente-funktioner uden `await`. Dette starter øjeblikkeligt alle tre netværksanmodninger. JavaScript-motoren overdrager dem til det underliggende miljø (browseren eller Node.js) og modtager tre ventende Promises tilbage. Derefter bruges `Promise.all()` til at vente på, at alle tre af disse promises bliver fuldført. Den samlede tid bestemmes nu af den længst kørende operation, ikke summen.
Total Tid ≈ max(500ms, 800ms, 1000ms) = 1000ms
Vi har netop skåret vores datahentningstid ned med mere end halvdelen! Dette er det grundlæggende princip i parallel promise-behandling. Lad os nu udforske de kraftfulde værktøjer, JavaScript tilbyder til at orkestrere disse samtidige opgaver.
Værktøjskassen til Promise Combinators: `all`, `allSettled`, `race` og `any`
JavaScript tilbyder fire statiske metoder på `Promise`-objektet, kendt som promise combinators. Hver af dem tager en itererbar (som et array) af promises og returnerer et nyt enkelt promise. Adfærden for dette nye promise afhænger af, hvilken kombinator du bruger.
1. `Promise.all()`: Alt-eller-intet-tilgangen
Promise.all() er det perfekte værktøj, når du har en gruppe opgaver, der alle er kritiske for det næste skridt. Det repræsenterer den logiske "OG"-betingelse: Opgave 1 OG Opgave 2 OG Opgave 3 skal alle lykkes.
- Input: En itererbar af promises.
- Adfærd: Den returnerer et enkelt promise, der fuldføres, når alle input-promises er fuldført. Den fuldførte værdi er et array af resultaterne fra input-promises, i samme rækkefølge.
- Fejltilstand: Den afviser (rejects) øjeblikkeligt, så snart et af input-promises afvises. Afvisningsårsagen er årsagen fra det første promise, der blev afvist. Dette kaldes ofte "fail-fast"-adfærd.
Anvendelsesscenarie: Kritisk Dataindsamling
Vores dashboard-eksempel er et perfekt anvendelsesscenarie. Hvis du ikke kan indlæse brugerens profil, giver det måske ikke mening at vise deres indlæg og notifikationer. Hele komponenten afhænger af, at alle tre datapunkter er tilgængelige.
// Hjælpefunktion til at simulere API-kald
const mockApiCall = (value, delay, shouldFail = false) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error(`API-kald fejlede for: ${value}`));
} else {
console.log(`Fuldført: ${value}`);
resolve({ data: value });
}
}, delay);
});
};
async function loadCriticalData() {
console.log('Bruger Promise.all til kritiske data...');
try {
const [profile, settings, permissions] = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700),
mockApiCall('userPermissions', 500)
]);
console.log('Alle kritiske data er indlæst succesfuldt!');
// Nu renderes UI med profil, indstillinger og tilladelser
} catch (error) {
console.error('Kunne ikke indlæse kritiske data:', error.message);
// Vis en fejlmeddelelse til brugeren
}
}
// Hvad sker der, hvis en fejler?
async function loadCriticalDataWithFailure() {
console.log('\nDemonstrerer Promise.all-fejl...');
try {
const results = await Promise.all([
mockApiCall('userProfile', 400),
mockApiCall('userSettings', 700, true), // Denne vil fejle
mockApiCall('userPermissions', 500)
]);
} catch (error) {
console.error('Promise.all blev afvist:', error.message);
// Bemærk: 'userProfile'- og 'userPermissions'-kaldene er muligvis fuldført,
// men deres resultater er tabt, fordi hele operationen fejlede.
}
}
loadCriticalData();
// Efter en forsinkelse, kald fejl-eksemplet
setTimeout(loadCriticalDataWithFailure, 2000);
Faldgruben ved `Promise.all()`
Den primære faldgrube er dens fail-fast-natur. Hvis du henter data til ti forskellige, uafhængige widgets på en side, og ét API fejler, vil `Promise.all()` afvise, og du vil miste resultaterne for de andre ni succesfulde kald. Det er her, vores næste kombinator kommer til sin ret.
2. `Promise.allSettled()`: Den robuste indsamler
Introduceret i ES2020, var `Promise.allSettled()` en game-changer for robusthed. Det er designet til, når du vil kende udfaldet af hvert enkelt promise, uanset om det lykkedes eller fejlede. Det afviser aldrig.
- Input: En itererbar af promises.
- Adfærd: Den returnerer et enkelt promise, der altid fuldføres. Det fuldføres, når alle input-promises er afgjort (enten fuldført eller afvist). Den fuldførte værdi er et array af objekter, der hver beskriver udfaldet af et promise.
- Resultatformat: Hvert resultatobjekt har en `status`-egenskab.
- Hvis fuldført: `{ status: 'fulfilled', value: theResult }`
- Hvis afvist: `{ status: 'rejected', reason: theError }`
Anvendelsesscenarie: Ikke-kritiske, Uafhængige Operationer
Forestil dig en side, der viser flere uafhængige komponenter: en vejr-widget, et nyhedsfeed og en aktie-ticker. Hvis nyhedsfeed-API'et fejler, vil du stadig gerne vise vejr- og aktieinformation. `Promise.allSettled()` er perfekt til dette.
async function loadDashboardWidgets() {
console.log('\nBruger Promise.allSettled til uafhængige widgets...');
const results = await Promise.allSettled([
mockApiCall('Vejrdata', 600),
mockApiCall('Nyhedsfeed', 1200, true), // Dette API er nede
mockApiCall('Aktie-ticker', 800)
]);
console.log('Alle promises er afgjort. Behandler resultater...');
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Widget ${index} indlæst succesfuldt med data:`, result.value.data);
// Render denne widget i UI
} else {
console.error(`Widget ${index} kunne ikke indlæses:`, result.reason.message);
// Vis en specifik fejltilstand for denne widget
}
});
}
loadDashboardWidgets();
Med `Promise.allSettled()` bliver din applikation meget mere robust. Et enkelt fejlpunkt forårsager ikke en kaskade, der bringer hele brugergrænsefladen ned. Du kan håndtere hvert udfald elegant.
3. `Promise.race()`: Først over målstregen
Promise.race() gør præcis, hvad navnet antyder. Det sætter en gruppe promises op mod hinanden og erklærer en vinder, så snart den første krydser målstregen, uanset om det var en succes eller en fiasko.
- Input: En itererbar af promises.
- Adfærd: Den returnerer et enkelt promise, der afgøres (fuldføres eller afvises), så snart det første af input-promises afgøres. Fuldførelsesværdien eller afvisningsårsagen for det returnerede promise vil være den samme som for det "vindende" promise.
- Vigtig bemærkning: De andre promises annulleres ikke. De vil fortsætte med at køre i baggrunden, og deres resultater vil simpelthen blive ignoreret af `Promise.race()`-konteksten.
Anvendelsesscenarie: Implementering af en Timeout
Det mest almindelige og praktiske anvendelsesscenarie for `Promise.race()` er at håndhæve en timeout på en asynkron operation. Du kan lade din hovedoperation "kappe" mod et `setTimeout`-promise. Hvis din operation tager for lang tid, vil timeout-promiset afgøres først, og du kan håndtere det som en fejl.
function createTimeout(delay) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Operation timede ud efter ${delay}ms`));
}, delay);
});
}
async function fetchDataWithTimeout() {
console.log('\nBruger Promise.race til en timeout...');
try {
const result = await Promise.race([
mockApiCall('nogle kritiske data', 2000), // Dette vil tage for lang tid
createTimeout(1500) // Dette vil vinde kapløbet
]);
console.log('Data hentet succesfuldt:', result.data);
} catch (error) {
console.error(error.message);
}
}
fetchDataWithTimeout();
Et andet anvendelsesscenarie: Redundante Endpoints
Du kunne også bruge `Promise.race()` til at forespørge flere redundante servere for den samme ressource og tage svaret fra den server, der er hurtigst. Dette er dog risikabelt, for hvis den hurtigste server returnerer en fejl (f.eks. en 500-statuskode), vil `Promise.race()` afvise øjeblikkeligt, selvom en lidt langsommere server ville have returneret et succesfuldt svar. Dette fører os til vores sidste, mere passende kombinator til dette scenarie.
4. `Promise.any()`: Den første, der lykkes
Introduceret i ES2021, er `Promise.any()` som en mere optimistisk version af `Promise.race()`. Den venter også på, at det første promise afgøres, men den leder specifikt efter det første, der fuldføres.
- Input: En itererbar af promises.
- Adfærd: Den returnerer et enkelt promise, der fuldføres, så snart et af input-promises fuldføres. Fuldførelsesværdien er værdien fra det første promise, der blev fuldført.
- Fejltilstand: Den afviser kun, hvis alle input-promises afvises. Afvisningsårsagen er et særligt `AggregateError`-objekt, som indeholder en `errors`-egenskab—et array af alle de individuelle afvisningsårsager.
Anvendelsesscenarie: Hentning fra Redundante Kilder
Dette er det perfekte værktøj til at hente en ressource fra flere kilder, som primære og backup-servere eller flere Content Delivery Networks (CDN'er). Du er kun interesseret i at få ét succesfuldt svar så hurtigt som muligt.
async function fetchResourceFromMirrors() {
console.log('\nBruger Promise.any til at finde den hurtigste succesfulde kilde...');
try {
const resource = await Promise.any([
mockApiCall('Primær CDN', 800, true), // Fejler hurtigt
mockApiCall('Europæisk Mirror', 1200), // Langsommere, men vil lykkes
mockApiCall('Asiatisk Mirror', 1100) // Lykkes også, men er langsommere end den europæiske
]);
console.log('Ressource hentet succesfuldt fra et mirror:', resource.data);
} catch (error) {
if (error instanceof AggregateError) {
console.error('Alle mirrors kunne ikke levere ressourcen.');
// Du kan inspicere individuelle fejl:
error.errors.forEach(err => console.log('- ' + err.message));
}
}
}
fetchResourceFromMirrors();
I dette eksempel vil `Promise.any()` ignorere den hurtige fejl fra den primære CDN og vente på, at det europæiske mirror fuldføres, hvorefter det vil blive afklaret med disse data og effektivt ignorere resultatet fra det asiatiske mirror.
Valg af det rette værktøj: En hurtig guide
Med fire kraftfulde muligheder, hvordan beslutter du, hvilken du skal bruge? Her er en simpel beslutningsramme:
- Har jeg brug for resultaterne af ALLE promises, og er det en katastrofe, hvis BARE ÉT af dem fejler?
BrugPromise.all(). Dette er til tæt koblede alt-eller-intet-scenarier. - Har jeg brug for at kende udfaldet af ALLE promises, uanset om de lykkes eller fejler?
BrugPromise.allSettled(). Dette er til håndtering af flere uafhængige opgaver, hvor du vil behandle hvert udfald og bevare applikationens robusthed. - Er jeg kun interesseret i det allerførste promise, der bliver færdigt, uanset om det er en succes eller en fiasko?
BrugPromise.race(). Dette er primært til implementering af timeouts eller andre kapløbssituationer, hvor kun det første resultat (af enhver art) betyder noget. - Er jeg kun interesseret i det første promise, der LYKKES, og kan jeg ignorere dem, der fejler?
BrugPromise.any(). Dette er til scenarier, der involverer redundans, som f.eks. at prøve flere endpoints for den samme ressource.
Avancerede Mønstre og Overvejelser fra den Virkelige Verden
Selvom promise combinators er utroligt kraftfulde, kræver professionel udvikling ofte lidt mere nuance.
Begrænsning af Concurrency og Throttling
Hvad sker der, hvis du har et array med 1.000 ID'er, og du vil hente data for hver enkelt? Hvis du naivt sender alle 1.000 promise-genererende kald ind i `Promise.all()`, vil du øjeblikkeligt affyre 1.000 netværksanmodninger. Dette kan have flere negative konsekvenser:
- Serveroverbelastning: Du kan overvælde den server, du anmoder fra, hvilket fører til fejl eller forringet ydeevne for alle brugere.
- Rate Limiting: De fleste offentlige API'er har rate limits. Du vil sandsynligvis ramme din grænse og modtage `429 Too Many Requests`-fejl.
- Klientressourcer: Klienten (browser eller server) kan have svært ved at håndtere så mange åbne netværksforbindelser på én gang.
Løsningen er at begrænse concurrency ved at behandle promises i batches. Selvom du kan skrive din egen logik til dette, håndterer modne biblioteker som `p-limit` eller `async-pool` dette elegant. Her er et konceptuelt eksempel på, hvordan du kan gribe det an manuelt:
async function processInBatches(items, batchSize, processingFn) {
let results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
console.log(`Behandler batch startende ved indeks ${i}...`);
const batchPromises = batch.map(processingFn);
const batchResults = await Promise.allSettled(batchPromises);
results = results.concat(batchResults);
}
return results;
}
// Eksempel på brug:
const userIds = Array.from({ length: 20 }, (_, i) => i + 1);
// Vi vil behandle 20 brugere i batches af 5
processInBatches(userIds, 5, id => mockApiCall(`user_${id}`, Math.random() * 1000))
.then(allResults => {
console.log('\nBatch-behandling fuldført.');
const successful = allResults.filter(r => r.status === 'fulfilled').length;
const failed = allResults.filter(r => r.status === 'rejected').length;
console.log(`Samlede resultater: ${allResults.length}, Succesfulde: ${successful}, Fejlede: ${failed}`);
});
En note om annullering
En langvarig udfordring med native Promises er, at de ikke kan annulleres. Når du først har oprettet et promise, vil det køre til fuldførelse. Selvom `Promise.race` kan hjælpe dig med at ignorere et langsomt resultat, fortsætter den underliggende operation med at forbruge ressourcer. For netværksanmodninger er den moderne løsning `AbortController`-API'et, som giver dig mulighed for at signalere til en `fetch`-anmodning, at den skal afbrydes. Integration af `AbortController` med promise combinators kan give en robust måde at administrere og rydde op i langvarige samtidige opgaver.
Konklusion: Fra Sekventiel til Samtidig Tænkning
At mestre asynkron JavaScript er en rejse. Den begynder med at forstå den enkelttrådede event loop, fortsætter til at bruge Promises og `async/await` for klarhedens skyld, og kulminerer i at tænke samtidigt for at maksimere performance. At skifte fra en sekventiel `await`-tankegang til en parallel-først-tilgang er en af de mest effektfulde ændringer, en udvikler kan foretage for at forbedre applikationens responsivitet.
Ved at udnytte de indbyggede promise combinators er du rustet til at håndtere en bred vifte af virkelige scenarier med elegance og præcision:
- Brug `Promise.all()` til kritiske, alt-eller-intet dataafhængigheder.
- Stol på `Promise.allSettled()` for at bygge robuste UI'er med uafhængige komponenter.
- Anvend `Promise.race()` til at håndhæve tidsbegrænsninger og forhindre uendelige ventetider.
- Vælg `Promise.any()` for at skabe hurtige og fejltolerante systemer med redundante datakilder.
Næste gang du skriver flere `await`-sætninger i træk, så stop op og spørg dig selv: "Er disse operationer virkelig afhængige af hinanden?" Hvis svaret er nej, har du en oplagt mulighed for at refaktorere din kode til concurrency. Begynd at starte dine promises sammen, vælg den rigtige kombinator til din logik, og se din applikations performance stige til vejrs.